// Copyright (c) 2022 NetEase, Inc. All rights reserved.
// Use of this source code is governed by a MIT license that can be
// found in the LICENSE file.

package com.netease.yunxin.kit.chatkit.ui.view.media;

import android.annotation.SuppressLint;
import android.content.Context;
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Matrix;
import android.graphics.Paint;
import android.graphics.Paint.Style;
import android.graphics.Rect;
import android.graphics.RectF;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.util.AttributeSet;
import android.view.GestureDetector.OnDoubleTapListener;
import android.view.GestureDetector.OnGestureListener;
import android.view.MotionEvent;
import android.view.ScaleGestureDetector;
import android.view.View;
import android.view.ViewConfiguration;
import androidx.core.view.GestureDetectorCompat;
import androidx.core.view.ScaleGestureDetectorCompat;
import com.netease.yunxin.kit.chatkit.ui.R;

/** Layout for the photo list watching */
public class PhotoView extends View
    implements OnGestureListener, OnDoubleTapListener, ScaleGestureDetector.OnScaleGestureListener {

  public static final int TRANSLATE_NONE = 0;
  public static final int TRANSLATE_X_ONLY = 1;
  public static final int TRANSLATE_Y_ONLY = 2;
  public static final int TRANSLATE_BOTH = 3;

  /** Zoom animation duration; in milliseconds */
  private static final long ZOOM_ANIMATION_DURATION = 200L;
  /** Amount of time to wait after over-zooming before the zoom out animation; in milliseconds */
  private static final long ZOOM_CORRECTION_DELAY = 600L;
  /** Rotate animation duration; in milliseconds */
  private static final long ROTATE_ANIMATION_DURATION = 500L;
  /** Snap animation duration; in milliseconds */
  private static final long SNAP_DURATION = 100L;
  /** Amount of time to wait before starting snap animation; in milliseconds */
  private static final long SNAP_DELAY = 250L;
  /** By how much to scale the image when double click occurs */
  private static final float DOUBLE_TAP_SCALE_FACTOR = 2.0f;
  /** Amount which can be zoomed in past the maximum scale, and then scaled back */
  private static final float SCALE_OVERZOOM_FACTOR = 1.5f;
  /** Amount of translation needed before starting a snap animation */
  private static final float SNAP_THRESHOLD = 20.0f;
  /** The width & height of the bitmap returned by {@link #getCroppedPhoto()} */
  private static final float CROPPED_SIZE = 256.0f;

  /**
   * Touch slop used to determine if this double tap is valid for starting a scale or should be
   * ignored.
   */
  private static int sTouchSlopSquare;

  /** If {@code true}, the static values have been initialized */
  private static boolean sInitialized;

  // Various dimensions
  /** Width & height of the crop region */
  private static int sCropSize;

  // Bitmaps
  /** Video icon */
  private static Bitmap sVideoImage;
  /** Video icon */
  private static Bitmap sVideoNotReadyImage;

  // Paints
  /** Paint to partially dim the photo during crop */
  private static Paint sCropDimPaint;
  /** Paint to highlight the cropped portion of the photo */
  private static Paint sCropPaint;

  /** The photo to display */
  private Drawable mDrawable;
  /** The matrix used for drawing; this may be {@code null} */
  private Matrix mDrawMatrix;
  /** A matrix to apply the scaling of the photo */
  private Matrix mMatrix = new Matrix();
  /** The original matrix for this image; used to reset any transformations applied by the user */
  private Matrix mOriginalMatrix = new Matrix();

  /** The fixed height of this view. If {@code -1}, calculate the height */
  private int mFixedHeight = -1;
  /** When {@code true}, the view has been laid out */
  private boolean mHaveLayout;
  /** Whether or not the photo is full-screen */
  private boolean mFullScreen;
  /** Whether or not this is a still image of a video */
  private byte[] mVideoBlob;
  /** Whether or not this is a still image of a video */
  private boolean mVideoReady;

  /** Whether or not crop is allowed */
  private boolean mAllowCrop;
  /** The crop region */
  private Rect mCropRect = new Rect();
  /** Actual crop size; may differ from {@link #sCropSize} if the screen is smaller */
  private int mCropSize;
  /** The maximum amount of scaling to apply to images */
  private float mMaxInitialScaleFactor = 1.0f;

  /** Gesture detector */
  private GestureDetectorCompat mGestureDetector;
  /** Gesture detector that detects pinch gestures */
  private ScaleGestureDetector mScaleGetureDetector;
  /** An external click listener */
  private OnClickListener mExternalClickListener;
  /** When {@code true}, allows gestures to scale / pan the image */
  private boolean mTransformsEnabled;

  // To support zooming
  /** When {@code true}, a double tap scales the image by {@link #DOUBLE_TAP_SCALE_FACTOR} */
  private boolean mDoubleTapToZoomEnabled = true;
  /** When {@code true}, prevents scale end gesture from falsely triggering a double click. */
  private boolean mDoubleTapDebounce;
  /** When {@code false}, event is a scale gesture. Otherwise, event is a double touch. */
  private boolean mIsDoubleTouch;
  /** Runnable that scales the image */
  private ScaleRunnable mScaleRunnable;
  /** Minimum scale the image can have. */
  private float mMinScale;
  /** Maximum scale to limit scaling to, 0 means no limit. */
  private float mMaxScale;

  // To support translation [i.e. panning]
  /** Runnable that can move the image */
  private TranslateRunnable mTranslateRunnable;

  private SnapRunnable mSnapRunnable;

  // To support rotation
  /** The rotate runnable used to animate rotations of the image */
  private RotateRunnable mRotateRunnable;
  /** The current rotation amount, in degrees */
  private float mRotation;

  // Convenience fields
  // These are declared here not because they are important properties of the view. Rather, we
  // declare them here to avoid object allocation during critical graphics operations; such as
  // layout or drawing.
  /** Source (i.e. the photo size) bounds */
  private RectF mTempSrc = new RectF();
  /** Destination (i.e. the display) bounds. The image is scaled to this size. */
  private RectF mTempDst = new RectF();
  /** Rectangle to handle translations */
  private RectF mTranslateRect = new RectF();
  /** Array to store a copy of the matrix values */
  private float[] mValues = new float[9];

  /** Track whether a double tap event occurred. */
  private boolean mDoubleTapOccurred;

  /**
   * X and Y coordinates for the current down event. Since mDoubleTapOccurred only contains the
   * information that there was a double tap event, use these to get the secondary tap information
   * to determine if a user has moved beyond touch slop.
   */
  private float mDownFocusX;

  private float mDownFocusY;

  /** Whether the QuickSale gesture is enabled. */
  private boolean mQuickScaleEnabled;

  private float lastX = 0f;
  private boolean firstItem = false;
  private boolean lastItem = false;

  public void setFirst(boolean first) {
    firstItem = first;
  }

  public void setLast(boolean last) {
    lastItem = last;
  }

  public PhotoView(Context context) {
    super(context);
    initialize();
  }

  public PhotoView(Context context, AttributeSet attrs) {
    super(context, attrs);
    initialize();
  }

  public PhotoView(Context context, AttributeSet attrs, int defStyle) {
    super(context, attrs, defStyle);
    initialize();
  }

  @Override
  public boolean dispatchTouchEvent(MotionEvent event) {
    switch (event.getAction()) {
      case MotionEvent.ACTION_DOWN:
        lastX = event.getX();
        getParent().requestDisallowInterceptTouchEvent(true);
        break;
      case MotionEvent.ACTION_MOVE:
        boolean interceptLeft = interceptMoveLeft();
        boolean interceptRight = interceptMoveRight();

        float x = event.getX();
        boolean disallow = disallowParentIntercept(interceptLeft, interceptRight, x);
        getParent().requestDisallowInterceptTouchEvent(disallow);
        lastX = x;
        break;
      default:
        break;
    }
    return super.dispatchTouchEvent(event);
  }

  private boolean disallowParentIntercept(boolean interceptLeft, boolean interceptRight, float x) {
    if (interceptLeft && interceptRight) {
      // at middle
      return true;
    }
    if (interceptLeft && (lastItem || (x > lastX))) {
      // at right-edge, allow move to left
      return true;
    }
    // at left-edge, allow move to right, except the first item.
    return interceptRight && (firstItem || (x < lastX));
  }

  @SuppressLint("ClickableViewAccessibility")
  @Override
  public boolean onTouchEvent(MotionEvent event) {
    if (mScaleGetureDetector == null || mGestureDetector == null) {
      // We're being destroyed; ignore any touch events
      return true;
    }

    mScaleGetureDetector.onTouchEvent(event);
    mGestureDetector.onTouchEvent(event);
    final int action = event.getAction();

    switch (action) {
      case MotionEvent.ACTION_UP:
      case MotionEvent.ACTION_CANCEL:
        if (!mTranslateRunnable.mRunning) {
          snap();
        }
        break;
    }

    return true;
  }

  @Override
  public boolean onDoubleTap(MotionEvent e) {
    mDoubleTapOccurred = true;
    if (!mQuickScaleEnabled) {
      return scale(e);
    }
    return false;
  }

  @Override
  public boolean onDoubleTapEvent(MotionEvent e) {
    final int action = e.getAction();
    boolean handled = false;

    switch (action) {
      case MotionEvent.ACTION_DOWN:
        if (mQuickScaleEnabled) {
          mDownFocusX = e.getX();
          mDownFocusY = e.getY();
        }
        break;
      case MotionEvent.ACTION_UP:
        if (mQuickScaleEnabled) {
          handled = scale(e);
        }
        break;
      case MotionEvent.ACTION_MOVE:
        if (mQuickScaleEnabled && mDoubleTapOccurred) {
          final int deltaX = (int) (e.getX() - mDownFocusX);
          final int deltaY = (int) (e.getY() - mDownFocusY);
          int distance = (deltaX * deltaX) + (deltaY * deltaY);
          if (distance > sTouchSlopSquare) {
            mDoubleTapOccurred = false;
          }
        }
        break;
    }
    return handled;
  }

  private boolean scale(MotionEvent e) {
    boolean handled = false;
    if (mDoubleTapToZoomEnabled && mTransformsEnabled && mDoubleTapOccurred) {
      if (!mDoubleTapDebounce) {
        float currentScale = getScale();
        float targetScale;
        float centerX, centerY;

        // Zoom out if not default scale, otherwise zoom in
        if (currentScale > mMinScale) {
          targetScale = mMinScale;
          float relativeScale = targetScale / currentScale;
          // Find the apparent origin for scaling that equals this scale and translate
          centerX =
              (getWidth() / 2f - relativeScale * mTranslateRect.centerX()) / (1 - relativeScale);
          centerY =
              (getHeight() / 2f - relativeScale * mTranslateRect.centerY()) / (1 - relativeScale);
        } else {
          targetScale = currentScale * DOUBLE_TAP_SCALE_FACTOR;
          // Ensure the target scale is within our bounds
          targetScale = Math.max(mMinScale, targetScale);
          targetScale = Math.min(mMaxScale, targetScale);
          float relativeScale = targetScale / currentScale;
          float widthBuffer = (getWidth() - mTranslateRect.width()) / relativeScale;
          float heightBuffer = (getHeight() - mTranslateRect.height()) / relativeScale;
          // Clamp the center if it would result in uneven borders
          if (mTranslateRect.width() <= widthBuffer * 2) {
            centerX = mTranslateRect.centerX();
          } else {
            centerX =
                Math.min(
                    Math.max(mTranslateRect.left + widthBuffer, e.getX()),
                    mTranslateRect.right - widthBuffer);
          }
          if (mTranslateRect.height() <= heightBuffer * 2) {
            centerY = mTranslateRect.centerY();
          } else {
            centerY =
                Math.min(
                    Math.max(mTranslateRect.top + heightBuffer, e.getY()),
                    mTranslateRect.bottom - heightBuffer);
          }
        }

        mScaleRunnable.start(currentScale, targetScale, centerX, centerY);
        handled = true;
      }
      mDoubleTapDebounce = false;
    }
    mDoubleTapOccurred = false;
    return handled;
  }

  @Override
  public boolean onSingleTapConfirmed(MotionEvent e) {
    if (mExternalClickListener != null && !mIsDoubleTouch) {
      mExternalClickListener.onClick(this);
    }
    mIsDoubleTouch = false;
    return true;
  }

  @Override
  public boolean onSingleTapUp(MotionEvent e) {
    return false;
  }

  @Override
  public void onLongPress(MotionEvent e) {}

  @Override
  public void onShowPress(MotionEvent e) {}

  @Override
  public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
    if (mTransformsEnabled && !mScaleRunnable.mRunning) {
      translate(-distanceX, -distanceY);
    }
    return true;
  }

  @Override
  public boolean onDown(MotionEvent e) {
    if (mTransformsEnabled) {
      mTranslateRunnable.stop();
      mSnapRunnable.stop();
    }
    return true;
  }

  @Override
  public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
    if (mTransformsEnabled && !mScaleRunnable.mRunning) {
      mTranslateRunnable.start(velocityX, velocityY);
    }
    return true;
  }

  @Override
  public boolean onScale(ScaleGestureDetector detector) {
    if (mTransformsEnabled && !mScaleRunnable.mRunning) {
      mIsDoubleTouch = false;
      float currentScale = getScale();
      float newScale = currentScale * detector.getScaleFactor();
      scale(newScale, detector.getFocusX(), detector.getFocusY());
    }
    return true;
  }

  @Override
  public boolean onScaleBegin(ScaleGestureDetector detector) {
    if (mTransformsEnabled && !mScaleRunnable.mRunning) {
      mScaleRunnable.stop();
      mIsDoubleTouch = true;
    }
    return true;
  }

  @Override
  public void onScaleEnd(ScaleGestureDetector detector) {
    if (mTransformsEnabled && mIsDoubleTouch) {
      mDoubleTapDebounce = true;
      resetTransformations();
    }
  }

  @Override
  public void setOnClickListener(OnClickListener listener) {
    mExternalClickListener = listener;
  }

  public boolean interceptMoveLeft() {
    if (!mTransformsEnabled) {
      // Allow intercept if we're not in transform mode
      return false;
    } else if (mTranslateRunnable.mRunning) {
      // Don't allow touch intercept until we've stopped flinging
      return true;
    } else {
      mMatrix.getValues(mValues);
      mTranslateRect.set(mTempSrc);
      mMatrix.mapRect(mTranslateRect);

      final float viewWidth = getWidth();
      final float transX = mValues[Matrix.MTRANS_X];
      final float drawWidth = mTranslateRect.right - mTranslateRect.left;

      if (!mTransformsEnabled || drawWidth <= viewWidth) {
        // Allow intercept if not in transform mode or the image is smaller than the view
        return false;
      } else if (transX == 0) {
        // We're at the left-side of the image; allow intercepting movements to the right
        return false;
      } else if (viewWidth >= drawWidth + transX) {
        // We're at the right-side of the image; allow intercepting movements to the left
        return true;
      } else {
        // We're in the middle of the image; don't allow touch intercept
        return true;
      }
    }
  }

  public boolean interceptMoveRight() {
    if (!mTransformsEnabled) {
      // Allow intercept if we're not in transform mode
      return false;
    } else if (mTranslateRunnable.mRunning) {
      // Don't allow touch intercept until we've stopped flinging
      return true;
    } else {
      mMatrix.getValues(mValues);
      mTranslateRect.set(mTempSrc);
      mMatrix.mapRect(mTranslateRect);

      final float viewWidth = getWidth();
      final float transX = mValues[Matrix.MTRANS_X];
      final float drawWidth = mTranslateRect.right - mTranslateRect.left;

      if (!mTransformsEnabled || drawWidth <= viewWidth) {
        // Allow intercept if not in transform mode or the image is smaller than the view
        return false;
      } else if (transX == 0) {
        // We're at the left-side of the image; allow intercepting movements to the right
        return true;
      } else if (viewWidth >= drawWidth + transX) {
        // We're at the right-side of the image; allow intercepting movements to the left
        return false;
      } else {
        // We're in the middle of the image; don't allow touch intercept
        return true;
      }
    }
  }

  /**
   * Free all resources held by this view. The view is on its way to be collected and will not be
   * reused.
   */
  public void clear() {
    mGestureDetector = null;
    mScaleGetureDetector = null;
    mDrawable = null;
    mScaleRunnable.stop();
    mScaleRunnable = null;
    mTranslateRunnable.stop();
    mTranslateRunnable = null;
    mSnapRunnable.stop();
    mSnapRunnable = null;
    mRotateRunnable.stop();
    mRotateRunnable = null;
    setOnClickListener(null);
    mExternalClickListener = null;
    mDoubleTapOccurred = false;
  }

  public void bindDrawable(Drawable drawable) {
    boolean changed = false;
    if (drawable != null && drawable != mDrawable) {
      // Clear previous state.
      if (mDrawable != null) {
        mDrawable.setCallback(null);
      }

      mDrawable = drawable;

      // Reset mMinScale to ensure the bounds / matrix are recalculated
      mMinScale = 0f;

      // Set a callback?
      mDrawable.setCallback(this);

      changed = true;
    }

    configureBounds(changed);
    invalidate();
  }

  /**
   * Binds a bitmap to the view.
   *
   * @param photoBitmap the bitmap to bind.
   */
  public void bindPhoto(Bitmap photoBitmap) {
    boolean currentDrawableIsBitmapDrawable = mDrawable instanceof BitmapDrawable;
    boolean changed = !(currentDrawableIsBitmapDrawable);
    if (mDrawable != null && currentDrawableIsBitmapDrawable) {
      final Bitmap drawableBitmap = ((BitmapDrawable) mDrawable).getBitmap();
      if (photoBitmap == drawableBitmap) {
        // setting the same bitmap; do nothing
        return;
      }

      changed =
          photoBitmap != null
              && (mDrawable.getIntrinsicWidth() != photoBitmap.getWidth()
                  || mDrawable.getIntrinsicHeight() != photoBitmap.getHeight());

      // Reset mMinScale to ensure the bounds / matrix are recalculated
      mMinScale = 0f;
      mDrawable = null;
    }

    if (mDrawable == null && photoBitmap != null) {
      mDrawable = new BitmapDrawable(getResources(), photoBitmap);
    }

    configureBounds(changed);
    invalidate();
  }

  /** Returns the bound photo data if set. Otherwise, {@code null}. */
  public Bitmap getPhoto() {
    if (mDrawable != null && mDrawable instanceof BitmapDrawable) {
      return ((BitmapDrawable) mDrawable).getBitmap();
    }
    return null;
  }

  /** Returns the bound drawable. May be {@code null} if no drawable is bound. */
  public Drawable getDrawable() {
    return mDrawable;
  }

  /** Gets video data associated with this item. Returns {@code null} if this is not a video. */
  public byte[] getVideoData() {
    return mVideoBlob;
  }

  /** Returns {@code true} if the photo represents a video. Otherwise, {@code false}. */
  public boolean isVideo() {
    return mVideoBlob != null;
  }

  /** Returns {@code true} if the video is ready to play. Otherwise, {@code false}. */
  public boolean isVideoReady() {
    return mVideoBlob != null && mVideoReady;
  }

  /** Returns {@code true} if a photo has been bound. Otherwise, {@code false}. */
  public boolean isPhotoBound() {
    return mDrawable != null;
  }

  /**
   * Hides the photo info portion of the header. As a side effect, this automatically enables or
   * disables image transformations [eg zoom, pan, etc...] depending upon the value of fullScreen.
   * If this is not desirable, enable / disable image transformations manually.
   */
  public void setFullScreen(boolean fullScreen, boolean animate) {
    if (fullScreen != mFullScreen) {
      mFullScreen = fullScreen;
      requestLayout();
      invalidate();
    }
  }

  /**
   * Enable or disable cropping of the displayed image. Cropping can only be enabled <em>before</em>
   * the view has been laid out. Additionally, once cropping has been enabled, it cannot be
   * disabled.
   */
  public void enableAllowCrop(boolean allowCrop) {
    if (allowCrop && mHaveLayout) {
      throw new IllegalArgumentException("Cannot set crop after view has been laid out");
    }
    if (!allowCrop && mAllowCrop) {
      throw new IllegalArgumentException("Cannot unset crop mode");
    }
    mAllowCrop = allowCrop;
  }

  /** Gets a bitmap of the cropped region. If cropping is not enabled, returns {@code null}. */
  public Bitmap getCroppedPhoto() {
    if (!mAllowCrop) {
      return null;
    }

    final Bitmap croppedBitmap =
        Bitmap.createBitmap((int) CROPPED_SIZE, (int) CROPPED_SIZE, Bitmap.Config.ARGB_8888);
    final Canvas croppedCanvas = new Canvas(croppedBitmap);

    // scale for the final dimensions
    final int cropWidth = mCropRect.right - mCropRect.left;
    final float scaleWidth = CROPPED_SIZE / cropWidth;
    final float scaleHeight = CROPPED_SIZE / cropWidth;

    // translate to the origin & scale
    final Matrix matrix = new Matrix(mDrawMatrix);
    matrix.postTranslate(-mCropRect.left, -mCropRect.top);
    matrix.postScale(scaleWidth, scaleHeight);

    // draw the photo
    if (mDrawable != null) {
      croppedCanvas.concat(matrix);
      mDrawable.draw(croppedCanvas);
    }
    return croppedBitmap;
  }

  /** Resets the image transformation to its original value. */
  public void resetTransformations() {
    // snap transformations; we don't animate
    mMatrix.set(mOriginalMatrix);

    // Invalidate the view because if you move off this PhotoView
    // to another one and come back, you want it to draw from scratch
    // in case you were zoomed in or translated (since those settings
    // are not preserved and probably shouldn't be).
    invalidate();
  }

  /** Rotates the image 90 degrees, clockwise. */
  public void rotateClockwise() {
    rotate(90, true);
  }

  /** Rotates the image 90 degrees, counter clockwise. */
  public void rotateCounterClockwise() {
    rotate(-90, true);
  }

  @Override
  protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);

    // draw the photo
    if (mDrawable != null) {
      int saveCount = canvas.getSaveCount();
      canvas.save();

      if (mDrawMatrix != null) {
        canvas.concat(mDrawMatrix);
      }
      mDrawable.draw(canvas);

      canvas.restoreToCount(saveCount);

      if (mVideoBlob != null) {
        final Bitmap videoImage = (mVideoReady ? sVideoImage : sVideoNotReadyImage);
        final int drawLeft = (getWidth() - videoImage.getWidth()) / 2;
        final int drawTop = (getHeight() - videoImage.getHeight()) / 2;
        canvas.drawBitmap(videoImage, drawLeft, drawTop, null);
      }

      // Extract the drawable's bounds (in our own copy, to not alter the image)
      mTranslateRect.set(mDrawable.getBounds());
      if (mDrawMatrix != null) {
        mDrawMatrix.mapRect(mTranslateRect);
      }

      if (mAllowCrop) {
        int previousSaveCount = canvas.getSaveCount();
        canvas.drawRect(0, 0, getWidth(), getHeight(), sCropDimPaint);
        canvas.save();
        canvas.clipRect(mCropRect);

        if (mDrawMatrix != null) {
          canvas.concat(mDrawMatrix);
        }

        mDrawable.draw(canvas);
        canvas.restoreToCount(previousSaveCount);
        canvas.drawRect(mCropRect, sCropPaint);
      }
    }
  }

  @Override
  protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
    super.onLayout(changed, left, top, right, bottom);
    mHaveLayout = true;
    final int layoutWidth = getWidth();
    final int layoutHeight = getHeight();

    if (mAllowCrop) {
      mCropSize = Math.min(sCropSize, Math.min(layoutWidth, layoutHeight));
      final int cropLeft = (layoutWidth - mCropSize) / 2;
      final int cropTop = (layoutHeight - mCropSize) / 2;
      final int cropRight = cropLeft + mCropSize;
      final int cropBottom = cropTop + mCropSize;

      // Create a crop region overlay. We need a separate canvas to be able to "punch
      // a hole" through to the underlying image.
      mCropRect.set(cropLeft, cropTop, cropRight, cropBottom);
    }
    configureBounds(changed);
  }

  @Override
  protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    if (mFixedHeight != -1) {
      super.onMeasure(
          widthMeasureSpec, MeasureSpec.makeMeasureSpec(mFixedHeight, MeasureSpec.AT_MOST));
      setMeasuredDimension(getMeasuredWidth(), mFixedHeight);
    } else {
      super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    }
  }

  @Override
  public boolean verifyDrawable(Drawable drawable) {
    return mDrawable == drawable || super.verifyDrawable(drawable);
  }

  @Override
  public void invalidateDrawable(Drawable drawable) {
    // Only invalidate this view if the passed in drawable is displayed within this view. If
    // another drawable is passed in, have the parent view handle invalidation.
    if (mDrawable == drawable) {
      invalidate();
    } else {
      super.invalidateDrawable(drawable);
    }
  }

  /**
   * Forces a fixed height for this view.
   *
   * @param fixedHeight The height. If {@code -1}, use the measured height.
   */
  public void setFixedHeight(int fixedHeight) {
    final boolean adjustBounds = (fixedHeight != mFixedHeight);
    mFixedHeight = fixedHeight;
    setMeasuredDimension(getMeasuredWidth(), mFixedHeight);
    if (adjustBounds) {
      configureBounds(true);
      requestLayout();
    }
  }

  /**
   * Enable or disable image transformations. When transformations are enabled, this view consumes
   * all touch events.
   */
  public void enableImageTransforms(boolean enable) {
    mTransformsEnabled = enable;
    if (!mTransformsEnabled) {
      resetTransformations();
    }
  }

  /** Configures the bounds of the photo. The photo will always be scaled to fit center. */
  private void configureBounds(boolean changed) {
    if (mDrawable == null || !mHaveLayout) {
      return;
    }
    final int dwidth = mDrawable.getIntrinsicWidth();
    final int dheight = mDrawable.getIntrinsicHeight();

    final int vwidth = getWidth();
    final int vheight = getHeight();

    final boolean fits = (dwidth < 0 || vwidth == dwidth) && (dheight < 0 || vheight == dheight);

    // We need to do the scaling ourself, so have the drawable use its native size.
    mDrawable.setBounds(0, 0, dwidth, dheight);

    // Create a matrix with the proper transforms
    if (changed || (mMinScale == 0 && mDrawable != null && mHaveLayout)) {
      generateMatrix();
      generateScale();
    }

    if (fits || mMatrix.isIdentity()) {
      // The bitmap fits exactly, no transform needed.
      mDrawMatrix = null;
    } else {
      mDrawMatrix = mMatrix;
    }
  }

  /**
   * Generates the initial transformation matrix for drawing. Additionally, it sets the minimum and
   * maximum scale values.
   */
  private void generateMatrix() {
    final int dwidth = mDrawable.getIntrinsicWidth();
    final int dheight = mDrawable.getIntrinsicHeight();

    final int vwidth = mAllowCrop ? sCropSize : getWidth();
    final int vheight = mAllowCrop ? sCropSize : getHeight();

    final boolean fits = (dwidth < 0 || vwidth == dwidth) && (dheight < 0 || vheight == dheight);

    if (fits && !mAllowCrop) {
      mMatrix.reset();
    } else {
      // Generate the required transforms for the photo
      mTempSrc.set(0, 0, dwidth, dheight);
      if (mAllowCrop) {
        mTempDst.set(mCropRect);
      } else {
        mTempDst.set(0, 0, vwidth, vheight);
      }
      RectF scaledDestination =
          new RectF(
              (vwidth / 2f) - (dwidth * mMaxInitialScaleFactor / 2),
              (vheight / 2f) - (dheight * mMaxInitialScaleFactor / 2),
              (vwidth / 2f) + (dwidth * mMaxInitialScaleFactor / 2),
              (vheight / 2f) + (dheight * mMaxInitialScaleFactor / 2));
      if (mTempDst.contains(scaledDestination)) {
        mMatrix.setRectToRect(mTempSrc, scaledDestination, Matrix.ScaleToFit.CENTER);
      } else {
        mMatrix.setRectToRect(mTempSrc, mTempDst, Matrix.ScaleToFit.CENTER);
      }
    }
    mOriginalMatrix.set(mMatrix);
  }

  private void generateScale() {
    final int dwidth = mDrawable.getIntrinsicWidth();
    final int dheight = mDrawable.getIntrinsicHeight();

    final int vwidth = mAllowCrop ? getCropSize() : getWidth();
    final int vheight = mAllowCrop ? getCropSize() : getHeight();

    if (dwidth < vwidth && dheight < vheight && !mAllowCrop) {
      mMinScale = mMaxInitialScaleFactor;
    } else {
      mMinScale = getScale();
    }
    mMaxScale = Math.max(mMinScale * 4, 4);
  }

  /** @return the size of the crop regions */
  private int getCropSize() {
    return mCropSize > 0 ? mCropSize : sCropSize;
  }

  /**
   * Returns the currently applied scale factor for the image.
   *
   * <p>NOTE: This method overwrites any values stored in {@link #mValues}.
   */
  private float getScale() {
    mMatrix.getValues(mValues);
    return mValues[Matrix.MSCALE_X];
  }

  /**
   * Scales the image while keeping the aspect ratio.
   *
   * <p>The given scale is capped so that the resulting scale of the image always remains between
   * {@link #mMinScale} and {@link #mMaxScale}.
   *
   * <p>If the image is smaller than the viewable area, it will be centered.
   *
   * @param newScale the new scale
   * @param centerX the center horizontal point around which to scale
   * @param centerY the center vertical point around which to scale
   */
  private void scale(float newScale, float centerX, float centerY) {
    // Rotate back to the original orientation
    mMatrix.postRotate(-mRotation, getWidth() / 2f, getHeight() / 2f);

    // Ensure that mMinScale <= newScale <= mMaxScale
    newScale = Math.max(newScale, mMinScale);
    newScale = Math.min(newScale, mMaxScale * SCALE_OVERZOOM_FACTOR);

    float currentScale = getScale();

    // Prepare to animate zoom out if over-zooming
    if (newScale > mMaxScale && currentScale <= mMaxScale) {
      Runnable zoomBackRunnable =
          () -> {
            // Scale back to the maximum if over-zoomed
            float currentScale1 = getScale();
            if (currentScale1 > mMaxScale) {
              // The number of times the crop amount pulled in can fit on the screen
              float marginFit = 1 / (1 - mMaxScale / currentScale1);
              // The (negative) relative maximum distance from an image edge such that
              // when scaled this far from the edge, all of the image off-screen in that
              // direction is pulled in
              float relativeDistance = 1 - marginFit;
              float finalCenterX = getWidth() / 2f;
              float finalCenterY = getHeight() / 2f;
              // This center will pull all of the margin from the lesser side, over will
              // expose trim
              float maxX = mTranslateRect.left * relativeDistance;
              float maxY = mTranslateRect.top * relativeDistance;
              // This center will pull all of the margin from the greater side, over will
              // expose trim
              float minX = getWidth() * marginFit + mTranslateRect.right * relativeDistance;
              float minY = getHeight() * marginFit + mTranslateRect.bottom * relativeDistance;
              // Adjust center according to bounds to avoid bad crop
              if (minX > maxX) {
                // Border is inevitable due to small image size, so we split the crop
                finalCenterX = (minX + maxX) / 2;
              } else {
                finalCenterX = Math.min(Math.max(minX, finalCenterX), maxX);
              }
              if (minY > maxY) {
                // Border is inevitable due to small image size, so we split the crop
                finalCenterY = (minY + maxY) / 2;
              } else {
                finalCenterY = Math.min(Math.max(minY, finalCenterY), maxY);
              }
              mScaleRunnable.start(currentScale1, mMaxScale, finalCenterX, finalCenterY);
            }
          };
      postDelayed(zoomBackRunnable, ZOOM_CORRECTION_DELAY);
    }

    float factor = newScale / currentScale;

    // Apply the scale factor
    mMatrix.postScale(factor, factor, centerX, centerY);

    // Re-apply any rotation
    mMatrix.postRotate(mRotation, getWidth() / 2f, getHeight() / 2f);

    invalidate();
  }

  /**
   * Translates the image.
   *
   * <p>This method will not allow the image to be translated outside of the visible area.
   *
   * @param tx how many pixels to translate horizontally
   * @param ty how many pixels to translate vertically
   * @return result of the translation, represented as either {@link #TRANSLATE_NONE}, {@link
   *     #TRANSLATE_X_ONLY}, {@link #TRANSLATE_Y_ONLY}, or {@link #TRANSLATE_BOTH}
   */
  private int translate(float tx, float ty) {
    mTranslateRect.set(mTempSrc);
    mMatrix.mapRect(mTranslateRect);

    final float maxLeft = mAllowCrop ? mCropRect.left : 0.0f;
    final float maxRight = mAllowCrop ? mCropRect.right : getWidth();
    float l = mTranslateRect.left;
    float r = mTranslateRect.right;

    final float translateX;
    if (mAllowCrop) {
      // If we're cropping, allow the image to scroll off the edge of the screen
      translateX =
          Math.max(maxLeft - mTranslateRect.right, Math.min(maxRight - mTranslateRect.left, tx));
    } else {
      // Otherwise, ensure the image never leaves the screen
      if (r - l < maxRight - maxLeft) {
        translateX = maxLeft + ((maxRight - maxLeft) - (r + l)) / 2;
      } else {
        translateX = Math.max(maxRight - r, Math.min(maxLeft - l, tx));
      }
    }

    float maxTop = mAllowCrop ? mCropRect.top : 0.0f;
    float maxBottom = mAllowCrop ? mCropRect.bottom : getHeight();
    float t = mTranslateRect.top;
    float b = mTranslateRect.bottom;

    final float translateY;
    if (mAllowCrop) {
      // If we're cropping, allow the image to scroll off the edge of the screen
      translateY =
          Math.max(maxTop - mTranslateRect.bottom, Math.min(maxBottom - mTranslateRect.top, ty));
    } else {
      // Otherwise, ensure the image never leaves the screen
      if (b - t < maxBottom - maxTop) {
        translateY = maxTop + ((maxBottom - maxTop) - (b + t)) / 2;
      } else {
        translateY = Math.max(maxBottom - b, Math.min(maxTop - t, ty));
      }
    }

    // Do the translation
    mMatrix.postTranslate(translateX, translateY);
    invalidate();

    boolean didTranslateX = translateX == tx;
    boolean didTranslateY = translateY == ty;
    if (didTranslateX && didTranslateY) {
      return TRANSLATE_BOTH;
    } else if (didTranslateX) {
      return TRANSLATE_X_ONLY;
    } else if (didTranslateY) {
      return TRANSLATE_Y_ONLY;
    }
    return TRANSLATE_NONE;
  }

  /** Snaps the image so it touches all edges of the view. */
  private void snap() {
    mTranslateRect.set(mTempSrc);
    mMatrix.mapRect(mTranslateRect);

    // Determine how much to snap in the horizontal direction [if any]
    float maxLeft = mAllowCrop ? mCropRect.left : 0.0f;
    float maxRight = mAllowCrop ? mCropRect.right : getWidth();
    float l = mTranslateRect.left;
    float r = mTranslateRect.right;

    final float translateX;
    if (r - l < maxRight - maxLeft) {
      // Image is narrower than view; translate to the center of the view
      translateX = maxLeft + ((maxRight - maxLeft) - (r + l)) / 2;
    } else if (l > maxLeft) {
      // Image is off right-edge of screen; bring it into view
      translateX = maxLeft - l;
    } else if (r < maxRight) {
      // Image is off left-edge of screen; bring it into view
      translateX = maxRight - r;
    } else {
      translateX = 0.0f;
    }

    // Determine how much to snap in the vertical direction [if any]
    float maxTop = mAllowCrop ? mCropRect.top : 0.0f;
    float maxBottom = mAllowCrop ? mCropRect.bottom : getHeight();
    float t = mTranslateRect.top;
    float b = mTranslateRect.bottom;

    final float translateY;
    if (b - t < maxBottom - maxTop) {
      // Image is shorter than view; translate to the bottom edge of the view
      translateY = maxTop + ((maxBottom - maxTop) - (b + t)) / 2;
    } else if (t > maxTop) {
      // Image is off bottom-edge of screen; bring it into view
      translateY = maxTop - t;
    } else if (b < maxBottom) {
      // Image is off top-edge of screen; bring it into view
      translateY = maxBottom - b;
    } else {
      translateY = 0.0f;
    }

    if (Math.abs(translateX) > SNAP_THRESHOLD || Math.abs(translateY) > SNAP_THRESHOLD) {
      mSnapRunnable.start(translateX, translateY);
    } else {
      mMatrix.postTranslate(translateX, translateY);
      invalidate();
    }
  }

  /**
   * Rotates the image, either instantly or gradually
   *
   * @param degrees how many degrees to rotate the image, positive rotates clockwise
   * @param animate if {@code true}, animate during the rotation. Otherwise, snap rotate.
   */
  private void rotate(float degrees, boolean animate) {
    if (animate) {
      mRotateRunnable.start(degrees);
    } else {
      mRotation += degrees;
      mMatrix.postRotate(degrees, getWidth() / 2f, getHeight() / 2f);
      invalidate();
    }
  }

  /** Initializes the header and any static values */
  private void initialize() {
    Context context = getContext();

    if (!sInitialized) {
      sInitialized = true;

      Resources resources = context.getApplicationContext().getResources();

      sCropSize = resources.getDimensionPixelSize(R.dimen.photo_crop_width);

      sCropDimPaint = new Paint();
      sCropDimPaint.setAntiAlias(true);
      sCropDimPaint.setColor(resources.getColor(R.color.photo_crop_dim_color));
      sCropDimPaint.setStyle(Style.FILL);

      sCropPaint = new Paint();
      sCropPaint.setAntiAlias(true);
      sCropPaint.setColor(resources.getColor(R.color.photo_crop_highlight_color));
      sCropPaint.setStyle(Style.STROKE);
      sCropPaint.setStrokeWidth(resources.getDimension(R.dimen.photo_crop_stroke_width));

      final ViewConfiguration configuration = ViewConfiguration.get(context);
      final int touchSlop = configuration.getScaledTouchSlop();
      sTouchSlopSquare = touchSlop * touchSlop;
    }

    mGestureDetector = new GestureDetectorCompat(context, this, null);
    mScaleGetureDetector = new ScaleGestureDetector(context, this);
    mQuickScaleEnabled = ScaleGestureDetectorCompat.isQuickScaleEnabled(mScaleGetureDetector);
    mScaleRunnable = new ScaleRunnable(this);
    mTranslateRunnable = new TranslateRunnable(this);
    mSnapRunnable = new SnapRunnable(this);
    mRotateRunnable = new RotateRunnable(this);
  }

  /** Runnable that animates an image scale operation. */
  private static class ScaleRunnable implements Runnable {

    private final PhotoView mHeader;

    private float mCenterX;
    private float mCenterY;

    private boolean mZoomingIn;

    private float mTargetScale;
    private float mStartScale;
    private float mVelocity;
    private long mStartTime;

    private boolean mRunning;
    private boolean mStop;

    public ScaleRunnable(PhotoView header) {
      mHeader = header;
    }

    /** Starts the animation. There is no target scale bounds check. */
    public boolean start(float startScale, float targetScale, float centerX, float centerY) {
      if (mRunning) {
        return false;
      }

      mCenterX = centerX;
      mCenterY = centerY;

      // Ensure the target scale is within the min/max bounds
      mTargetScale = targetScale;
      mStartTime = System.currentTimeMillis();
      mStartScale = startScale;
      mZoomingIn = mTargetScale > mStartScale;
      mVelocity = (mTargetScale - mStartScale) / ZOOM_ANIMATION_DURATION;
      mRunning = true;
      mStop = false;
      mHeader.post(this);
      return true;
    }

    /** Stops the animation in place. It does not snap the image to its final zoom. */
    public void stop() {
      mRunning = false;
      mStop = true;
    }

    @Override
    public void run() {
      if (mStop) {
        return;
      }

      // Scale
      long now = System.currentTimeMillis();
      long ellapsed = now - mStartTime;
      float newScale = (mStartScale + mVelocity * ellapsed);
      mHeader.scale(newScale, mCenterX, mCenterY);

      // Stop when done
      if (newScale == mTargetScale || (mZoomingIn == (newScale > mTargetScale))) {
        mHeader.scale(mTargetScale, mCenterX, mCenterY);
        stop();
      }

      if (!mStop) {
        mHeader.post(this);
      }
    }
  }

  /** Runnable that animates an image translation operation. */
  private static class TranslateRunnable implements Runnable {

    private static final float DECELERATION_RATE = 20000f;
    private static final long NEVER = -1L;

    private final PhotoView mHeader;

    private float mVelocityX;
    private float mVelocityY;

    private float mDecelerationX;
    private float mDecelerationY;

    private long mLastRunTime;
    private boolean mRunning;
    private boolean mStop;

    public TranslateRunnable(PhotoView header) {
      mLastRunTime = NEVER;
      mHeader = header;
    }

    /** Starts the animation. */
    public boolean start(float velocityX, float velocityY) {
      if (mRunning) {
        return false;
      }
      mLastRunTime = NEVER;
      mVelocityX = velocityX;
      mVelocityY = velocityY;

      float angle = (float) Math.atan2(mVelocityY, mVelocityX);
      mDecelerationX = (float) (DECELERATION_RATE * Math.cos(angle));
      mDecelerationY = (float) (DECELERATION_RATE * Math.sin(angle));

      mStop = false;
      mRunning = true;
      mHeader.post(this);
      return true;
    }

    /** Stops the animation in place. It does not snap the image to its final translation. */
    public void stop() {
      mRunning = false;
      mStop = true;
    }

    @Override
    public void run() {
      // See if we were told to stop:
      if (mStop) {
        return;
      }

      // Translate according to current velocities and time delta:
      long now = System.currentTimeMillis();
      float delta = (mLastRunTime != NEVER) ? (now - mLastRunTime) / 1000f : 0f;
      final int translateResult = mHeader.translate(mVelocityX * delta, mVelocityY * delta);
      mLastRunTime = now;
      // Slow down:
      float slowDownX = mDecelerationX * delta;
      if (Math.abs(mVelocityX) > Math.abs(slowDownX)) {
        mVelocityX -= slowDownX;
      } else {
        mVelocityX = 0f;
      }
      float slowDownY = mDecelerationY * delta;
      if (Math.abs(mVelocityY) > Math.abs(slowDownY)) {
        mVelocityY -= slowDownY;
      } else {
        mVelocityY = 0f;
      }

      // Stop when done
      if ((mVelocityX == 0f && mVelocityY == 0f) || translateResult == TRANSLATE_NONE) {
        stop();
        mHeader.snap();
      } else if (translateResult == TRANSLATE_X_ONLY) {
        mDecelerationX = (mVelocityX > 0) ? DECELERATION_RATE : -DECELERATION_RATE;
        mDecelerationY = 0;
        mVelocityY = 0f;
      } else if (translateResult == TRANSLATE_Y_ONLY) {
        mDecelerationX = 0;
        mDecelerationY = (mVelocityY > 0) ? DECELERATION_RATE : -DECELERATION_RATE;
        mVelocityX = 0f;
      }

      // See if we need to continue flinging:
      if (mStop) {
        return;
      }
      mHeader.post(this);
    }
  }

  /** Runnable that animates an image translation operation. */
  private static class SnapRunnable implements Runnable {

    private static final long NEVER = -1L;

    private final PhotoView mHeader;

    private float mTranslateX;
    private float mTranslateY;

    private long mStartRunTime;
    private boolean mRunning;
    private boolean mStop;

    public SnapRunnable(PhotoView header) {
      mStartRunTime = NEVER;
      mHeader = header;
    }

    /** Starts the animation. */
    public boolean start(float translateX, float translateY) {
      if (mRunning) {
        return false;
      }
      mStartRunTime = NEVER;
      mTranslateX = translateX;
      mTranslateY = translateY;
      mStop = false;
      mRunning = true;
      mHeader.postDelayed(this, SNAP_DELAY);
      return true;
    }

    /** Stops the animation in place. It does not snap the image to its final translation. */
    public void stop() {
      mRunning = false;
      mStop = true;
    }

    @Override
    public void run() {
      // See if we were told to stop:
      if (mStop) {
        return;
      }

      // Translate according to current velocities and time delta:
      long now = System.currentTimeMillis();
      float delta = (mStartRunTime != NEVER) ? (now - mStartRunTime) : 0f;

      if (mStartRunTime == NEVER) {
        mStartRunTime = now;
      }

      float transX;
      float transY;
      if (delta >= SNAP_DURATION) {
        transX = mTranslateX;
        transY = mTranslateY;
      } else {
        transX = (mTranslateX / (SNAP_DURATION - delta)) * 10f;
        transY = (mTranslateY / (SNAP_DURATION - delta)) * 10f;
        if (Math.abs(transX) > Math.abs(mTranslateX) || Float.isNaN(transX)) {
          transX = mTranslateX;
        }
        if (Math.abs(transY) > Math.abs(mTranslateY) || Float.isNaN(transY)) {
          transY = mTranslateY;
        }
      }

      mHeader.translate(transX, transY);
      mTranslateX -= transX;
      mTranslateY -= transY;

      if (mTranslateX == 0 && mTranslateY == 0) {
        stop();
      }

      // See if we need to continue flinging:
      if (mStop) {
        return;
      }
      mHeader.post(this);
    }
  }

  /** Runnable that animates an image rotation operation. */
  private static class RotateRunnable implements Runnable {

    private static final long NEVER = -1L;

    private final PhotoView mHeader;

    private float mTargetRotation;
    private float mAppliedRotation;
    private float mVelocity;
    private long mLastRuntime;

    private boolean mRunning;
    private boolean mStop;

    public RotateRunnable(PhotoView header) {
      mHeader = header;
    }

    /** Starts the animation. */
    public void start(float rotation) {
      if (mRunning) {
        return;
      }

      mTargetRotation = rotation;
      mVelocity = mTargetRotation / ROTATE_ANIMATION_DURATION;
      mAppliedRotation = 0f;
      mLastRuntime = NEVER;
      mStop = false;
      mRunning = true;
      mHeader.post(this);
    }

    /** Stops the animation in place. It does not snap the image to its final rotation. */
    public void stop() {
      mRunning = false;
      mStop = true;
    }

    @Override
    public void run() {
      if (mStop) {
        return;
      }

      if (mAppliedRotation != mTargetRotation) {
        long now = System.currentTimeMillis();
        long delta = mLastRuntime != NEVER ? now - mLastRuntime : 0L;
        float rotationAmount = mVelocity * delta;
        if (mAppliedRotation < mTargetRotation
                && mAppliedRotation + rotationAmount > mTargetRotation
            || mAppliedRotation > mTargetRotation
                && mAppliedRotation + rotationAmount < mTargetRotation) {
          rotationAmount = mTargetRotation - mAppliedRotation;
        }
        mHeader.rotate(rotationAmount, false);
        mAppliedRotation += rotationAmount;
        if (mAppliedRotation == mTargetRotation) {
          stop();
        }
        mLastRuntime = now;
      }

      if (mStop) {
        return;
      }
      mHeader.post(this);
    }
  }

  public void setMaxInitialScale(float f) {
    mMaxInitialScaleFactor = f;
  }
}
